Skip to content

Region-picking onboarding + per-region customization#95

Draft
kyleve wants to merge 13 commits into
mainfrom
cursor/region-onboarding
Draft

Region-picking onboarding + per-region customization#95
kyleve wants to merge 13 commits into
mainfrom
cursor/region-onboarding

Conversation

@kyleve

@kyleve kyleve commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Builds the deferred Where onboarding step: pick up to 5 US primary regions (map or filterable list, via a top segmented control), then customize each region's color / emoji / icon from predefined lists. Picks become the tracked-region set; picks + appearance persist in CloudKit-mirrored SwiftData, feed region styling, and round-trip through backups. Editable later in Settings, and a returning user can skip the whole manual flow by restoring a backup.

What's included

WhereCore — persistence & domain

  • New value types RegionColorToken, RegionAppearance, PrimaryRegion (Codable).
  • SDTrackedRegion carries appearance (colorRaw / emoji / symbolName) + orderIndex (all optional for CloudKit — additive, no migration scaffolding).
  • WhereStore.primaryRegions() and a single atomic setPrimaryRegions([PrimaryRegion]) (replace semantics: upserts + removals-by-omission in one perform). Picks are the tracked set.
  • Backups round-trip the picked looks: BackupArchive gains an additive primaryRegions: [PrimaryRegion] (with the legacy trackedRegions ids kept alongside for cross-version restores); export writes both, import restores via setPrimaryRegions (.replace exact, .merge unions with archive appearance winning).

WhereUI — flow & styling

  • PrimaryRegionSelectionModel (US-only, cap 5), RegionPickerView (segmented Map | List — tappable US-state polygons + searchable list; List is the default and map geometry loads lazily), RegionCustomizeView / RegionAppearanceEditor (color/emoji/icon grids + live RegionSummaryCard preview), RegionsSettingsView.
  • OnboardingView is an intro → pick → customize → location state machine in NavigationStacks with toolbar controls; Settings reuses the same picker/customize views. The intro also offers Restore from a backup, which imports (.replace) and skips the manual steps to the location ask.
  • Region looks resolve through @Environment(\.regionStyles) (a RegionStyleResolver), seeded by whereBroadwayRoot(regionStyles:) — from WhereSession (live on store changes), the WidgetSnapshot in the widget process, and services in App Intents. No global region.style; fallbacks come from RegionAppearanceCatalog.
  • WidgetSnapshot carries appearances so widgets render user choices; RegionPickerStyle stylesheet group added.

Notes / decisions

  • US-only: the picker offers US jurisdictions only; opening the Settings editor on a legacy default install (CA/NY/Canada/EU) converges to the US picks on save (Canada/EU ship low-res polygons and are being retired). Documented + tested.
  • Home/year ranking still ranks by day count; picking sets the tracked set + appearance, it doesn't re-order the year report.
  • The SDTrackedRegion soft-delete TODO stays open (untracking still deletes the row, matching prior behavior).

Testing

  • Unit tests: store round-trip/ordering/replace-removal, selection-model cap + commit + US-only convergence, RegionStyleResolver resolution/fallback, widget-snapshot appearance round-trip, RegionPickerStyle defaults, and backup archive + end-to-end store round-trips (incl. legacy fallback).
  • Full Stuff-iOS-Tests scheme green locally (-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2'); ./swiftformat --lint clean.

Docs

  • Updated WhereCore and WhereUI README + AGENTS; resolved the RegionStyle bespoke-table TODO; refreshed the now-inaccurate setTrackedRegion(false) comment (untracking is user-reachable now); added a WhereCore invariant that persisted-model changes must be reflected in backups end-to-end.

Review fixes folded in

Nav-stack/toolbar consistency, atomic single-transaction commit, environment-injected styling (replacing a global singleton; also makes edits/imports restyle reactively), restored .other glyph, capped-tap haptic + hint, lazy map-geometry parsing (List default), and a clamped customize step index.

kyleve added 13 commits July 15, 2026 19:08
Build the deferred onboarding step that lets the user pick up to 5 US
primary regions (map or searchable list, via a top segmented control)
and customize each region's color/emoji/icon from predefined lists.

- WhereCore: RegionColorToken / RegionAppearance / PrimaryRegion value
  types; SDTrackedRegion carries appearance + pick order; WhereStore
  primaryRegions() / setPrimaryRegion(). Picks are the tracked-region set.
- WhereUI: RegionAppearanceCatalog + RegionStyleRegistry make RegionStyle
  data-driven (seeded by WhereSession on launch/changes, and by the widget
  snapshot in the widget process); PrimaryRegionSelectionModel,
  RegionPickerView, RegionCustomizeView, RegionsSettingsView.
- OnboardingView is now an intro -> pick -> customize -> location state
  machine that commits picks before finishing; Settings gains a Regions
  editor. WidgetSnapshot carries appearances so widgets render user looks.
- Tests: store round-trip/ordering, selection-model cap, registry
  resolution, widget appearance; RegionPickerStyle stylesheet group + docs.
Address code-review finding #1 (onboarding List had no search field
because .searchable needs a navigation container) and make the
onboarding and Settings region flows visually consistent:

- Onboarding pick phase is a NavigationStack with a large title and a
  top-right Next (forward-only after the intro); its list is now
  filterable via .searchable.
- RegionCustomizeView moves Back/Next (Done on the last region) into the
  nav bar with the region name as the inline title and an X-of-N
  indicator; callers place it in a NavigationStack.
- Onboarding customize phase wraps it in a NavigationStack.
- RegionsSettingsView: pick shows Cancel + Next (large title, matching
  onboarding), customize delegates to RegionCustomizeView's toolbar;
  dropped the old bottom buttons so each phase owns its own bar.
Address code-review finding #2: the commit looped per-region
setPrimaryRegion/removePrimaryRegion calls, each its own store.perform,
so N picks fired N changes() pings (attribution rebuild, widget
republish, report reload, registry reseed each) and a mid-loop failure
left partial state.

Replace those single-region WhereServices/WhereStore calls with one
batch setPrimaryRegions([PrimaryRegion]) that has replace semantics:
upsert each entry (appearance + order) and delete any tracked row not in
the list, all inside a single perform. Removals now happen by omission,
so the model no longer tracks an initial set to diff against — the
selection fully describes the primary set. One transaction, one ping,
atomic. Tests updated to the batch API.
Address code-review finding #3. The app is US-only now (the legacy
Canada/EU regions ship low-resolution polygons and aren't pickable), so
seeding the editor from the default set and saving intentionally drops
the non-US regions. Spell that out in PrimaryRegionSelectionModel's
init(existing:) doc and pin it with a test: loading the default set into
the model selects only CA/NY, and committing narrows the tracked set to
those.
Address code-review finding #4: RegionStyle read from a process-wide
RegionStyleRegistry.shared singleton — fine in production but a latent
test-isolation hazard in the shared multi-bundle host, and it made
styles update lazily rather than reactively.

Replace it with a RegionStyleResolver injected through the environment
(\.regionStyles), seeded by whereBroadwayRoot(regionStyles:):
- the app passes WhereSession's live resolver (updated on launch +
  store changes), so a Settings edit / remote import now restyles the UI
  reactively;
- the widget process builds one from WidgetSnapshot.appearances;
- App Intents snippets build one from their services.
The default empty resolver yields fallback looks (previews, RegionViewer).

Views now read @Environment(\.regionStyles) and call
regionStyles.style(for:); the global region.style / RegionStyle.style(for:)
and the registry are gone. Renamed the file/test to RegionStyleResolver
and updated docs.
Address code-review finding #5: unifying the bespoke look table into
RegionAppearanceCatalog.defaultAppearance changed the user-visible
.other glyph. Restore location.magnifyingglass (the fallback default
isn't constrained to the picker's symbol catalog). EU/Canada looks are
left as-is since those regions are being retired (not pickable).
Address code-review finding #6: the List disables unselected rows at the
5-region cap, but the map silently ignored a tap on a new state. Now a
capped map tap fires a warning haptic (.sensoryFeedback), and an
at-capacity hint appears under the selection count in both modes, so the
map is as responsive/legible as the list.
Address code-review finding #7: the picker parsed all ~52 US-region
GeoJSON files on appear to back Map mode. Default to List (no geometry
needed) and only run the parse when Map mode is first shown (.task(id:
mode)), so a List-only user never pays the cost.
Address code-review finding #8: harden RegionCustomizeView so a
stale/out-of-range step index can never strand the user on the empty
state. Navigation and the step indicator now use a clamped effectiveIndex;
the empty (nil) state is reserved for a genuinely empty selection, which
the current flow never reaches.
The test helper primary(_:_:_:) collided with the local
'let primary = try await store.primaryRegions()' variables. Local batch
compilation resolved the call to the method, but CI's compilation mode
resolved it to the [PrimaryRegion] local ('cannot call value of
non-function type'). Rename the helper to pick() to remove the ambiguity.
Backups previously stored tracked regions as bare ids, so a restore
brought back the regions but not their picked color/emoji/icon. Carry
the full primary-region set (appearance + order) in the archive so a
restore is lossless.

- BackupArchive gains an additive primaryRegions: [PrimaryRegion]
  (decode-if-present), keeping the legacy trackedRegions ids alongside it
  for cross-version restores; resolvedPrimaryRegions falls back to those
  when primaryRegions is absent. PrimaryRegion is now Codable.
- Export writes both; import restores via setPrimaryRegions for .replace
  and a merge (archive appearance wins on overlap) for .merge.
- Tests: archive + end-to-end store round-trips, legacy fallback.
- WhereCore AGENTS: add an invariant that persisted-model changes must be
  reflected in the backup end-to-end (archive + export + import + test).
Offer 'Restore from a backup' on the onboarding intro so a returning
user can skip the manual region setup: it imports the chosen backup
(.replace, since it's a fresh install) and jumps straight to the
location ask. The location finale skips committing the manual selection
when a restore ran, so it doesn't wipe the regions the restore wrote.
Failures surface an alert and keep the user in onboarding.
Add a root AGENTS rule that a PR's title/description must be updated in
the same turn you push whenever the PR changes beyond a small bug fix
(new behavior/API/data model, migration, scope change, follow-up, or
approach-altering review fixes), reflect the end state, preserve human
edits, and use gh pr edit when the PR tooling can't target the repo.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant